You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The OneBot v11 standard, in Kotlin types and nothing else: the ids and statuses, the twenty message segment types with the unknown ones kept whole, a message in each of the three shapes the API takes, the CQ code format, the events of the four kinds plus an unknown one that keeps its JSON, the 38 public actions with the hidden one and the derived async and rate limited calls, and the layered result model. No transport and no library is involved, so the domain is testable on its own.
The module is discovered by AlexandriteLayout as channels.onebot, which gives it the built-in KSP index, the config root channels.onebot and the plugin id alexandrite-channel-onebot.
Four things a review of the standard found missing, each with the test that
would have said so:
- The HTTP transport sent its token only in the `Authorization` header,
while the standard also allows `?access_token=`, and an implementation
that reads only the query refused every call. It sends both now.
- An instance that serves one account accepted a reverse connection and an
HTTP report that named no account at all, because only a value that was
both present and different was refused. The account is what binds a
connection to an instance, so a missing one is refused like a wrong one.
The body of an HTTP report is read before that, so a report that is no
event is still told apart from one of another account.
- The reverse listener read no `X-Client-Role`, so an `API` client, which
sends no events, and an `Event` client, which answers no call, were taken
as if they carried both. The roles that carry both are served, the two
that do not are closed with their reason, and an implementation that names
no role keeps working, since older ones report none.
- A port of 0 was documented as the operating system's choice and refused by
the configuration, so the two now agree.
Verified: :libraries:channels:onebot:test passes, 74 tests.
In: a private chat is private:<user id>, a group is group:<group id>, a temporary session keeps the group as its chat and the sender as its thread, media becomes attachments, a reply becomes a quote, and a segment this version has no shape for becomes a display fact rather than being dropped. Out: plain text, Markdown images as image segments, and a reply leading with the reply segment. A failure of the implementation becomes a delivery failure the SDK knows.
OneBotLink is the one place that picks the transport of an instance and opens it, so the channel calls an action without knowing what carries it. OneBotChannel contributes itself under the channel type onebot and turns an outgoing message into the parameters of send_private_msg or send_group_msg. OneBotInbox takes a reported message into the agent as a submission of its chat. OneBotApi is the typed face of the 38 actions plus the hidden one and a raw escape, one instance of it per channel instance. call takes a suffix, so the derived _async and _rate_limited calls are reachable without giving up the request type, and a result this version cannot read keeps the answer it came from.
An integration test runs the plugin inside a real runtime through the testkit harness: the implementation reports a message, the agent is asked for a turn of that chat, and the answer goes back out through send_private_msg.
app depends on the channel, so the index the built-in list names is on the class path, and the example config documents an instance with the channel off by default. This makes the built-in runtime tests see the plugin that was listed but missing before.
A review of the pull request found twelve things. Five are defects and are
fixed here, with the test that would have caught each.
The account header of an HTTP report was looked up as the standard spells it,
while the head reader folds header names to lower case. The header was
therefore never read, and a report that named its account only there was
refused. A test that passed for the wrong reason covered it: its body named
the account too, so the missing lookup was invisible. That case now sends a
body that names none, and a second case covers the header alone.
An answer that came back over a socket was wrapped as data without reading
its status, so a `failed` answer was an `Ok` whose data was the envelope, and
a caller read a delivery that never happened. Socket answers now go through
the same classification as HTTP ones.
The echo counter was a plain long read and written from more than one caller,
so two calls could take the same echo, overwrite each other in the waiting
map, and leave one caller waiting until its timeout. It counts atomically.
The declared length of an HTTP report was used to allocate before it was
checked, so a client could ask the listener for an array of the size it
named; the later check also measured characters rather than bytes. The length
is checked against the limit before the allocation.
The remaining findings are recorded rather than fixed here: an `http_post`
instance has no endpoint to call, so its outgoing actions are unreachable and
the configuration does not let it name one; the data of an answer is read as
an object, so actions such as `get_friend_list` that answer with an array do
not fit their type; a temporary session replies to its group instead of its
sender; `autoEscape` cannot preserve CQ text because every message is sent as
an array; an unknown `message_type` is stored in `postType`; `rateLimit` and
`logUnreadableAnswers` are configured but unused; and a dropped event is not
counted.
Verified: :libraries:channels:onebot:test passes, 75 tests.
Three findings of the review that were deferred, and should not have been.
A temporary session of a group is a private message whose address keeps the
group as its chat and the sender as its thread, and the mapping already said
that a reply goes back to the sender. The channel ignored the thread and
picked `send_group_msg` for the group, so an answer to a private message was
posted into the group. `privateTemporary` names the sender of such a session
and both the action and the parameters ask for it first.
Every message was sent as an array of segments, which is a shape that cannot
be taken literally, so `autoEscape` asked an implementation to preserve CQ
text and then handed it the text already cut into segments. A message is sent
in the shape it was written in, and `auto_escape` is asked for only by a
message that is one piece of text, the only shape an implementation can take
as written.
Verified: :libraries:channels:onebot:test passes, 77 tests.
A review found that the data of an answer was read as an object, so an answer
that carries an array or nothing became an empty object. The standard answers
`get_friend_list` and `get_group_list` with arrays, so every call of those was
reported as one this version cannot read, and the typed action layer had
nothing to deserialize.
The answer now keeps its data as it arrived, and the object an implementation
answers a send with is read where it is rather than where it was assumed to
be. Tests cover an array, an object, and a report that arrives while nothing
reads it, which is counted from what was taken less what was read and what
still waits, because the queue answers that it took an event even when it
displaced one.
The `rateLimitIntervalMillis` and `logUnreadableAnswers` options are removed
rather than left in place unused: this plugin does not rate limit calls and
does not log the answers it cannot read, and an option that promises either
is worse than no option.
Also corrected: an unknown `message_type` was stored in `postType`, which the
property promises is the top-level `post_type`. The subtype stays in the raw
JSON. One existing test asserted the wrong value and now asserts the right
one.
Verified: :libraries:channels:onebot:test passes, 80 tests.
…eaves
A call of the reverse transport waited for its answer under its own timeout
even after the implementation that owed it had gone, so a caller was told
that a slow implementation had missed a deadline rather than that the
connection was lost. A close now ends every call that waits, with the reason
the connection gave, and a test drives a call that is never answered into a
close and reads the failure.
Verified: :libraries:channels:onebot:test passes, 81 tests.
A report whose declared length was over the limit was refused by reading it
as no report at all, which the implementation saw as a connection that
dropped rather than as a refusal it could act on. The length is still checked
before anything is allocated, and what the length declares is now read in
pieces and dropped so that the answer reaches a peer that is still sending
it. The piece size is what keeps the near-2 GiB allocation out.
Verified: :libraries:channels:onebot:test passes, 82 tests.
WebSocket transport failures escape from this method instead of becoming OneBotResult.Unreachable. A disconnected socket throws IllegalStateException, and the per-call timeout throws TimeoutCancellationException, so both Channel.send and the public typed API can throw while the HTTP path returns a result. Convert transport-owned timeout/connection failures here while still rethrowing caller cancellation.
Validate numeric reply IDs before constructing MessageId
This blindly constructs a numeric MessageId, but this same mapper creates delivered refs named sent and async when no platform ID is available. Reusing either delivered ref as replyTo therefore throws before sending, rather than returning a delivery failure. Validate that the target is a numeric OneBot message ID and handle unavailable/synthetic refs explicitly.
The mapping discards OneBotFailure.retryable for HTTP failures. In particular, an HTTP 5xx result is retryable by OneBotFailure, but it reaches Delivery.NotDelivered as non-retryable UNKNOWN, preventing normal retries. Pass the failure's retryability through (and classify retryable HTTP status failures as transient if appropriate).
A group member's card is the sender's display name, not the group's title. Putting it into ChatInfo.title mislabels every group chat with whichever member spoke most recently. The event has no standard group-name field, so leave the title null unless it is obtained separately.
Serialize typed request IDs using numeric ID serializers
All typed request IDs are written as JSON strings here and in the remaining request classes, bypassing the ID serializers that deliberately emit a number when it fits (OneBotIds.kt:109-112). OneBot declares these parameters as numeric, so strict WS/JSON implementations can reject otherwise valid typed calls. Encode UserId, GroupId, and MessageId through their serializers (falling back to strings only when outside Long).
A valid JSON scalar or array is converted into a synthetic Unknown event and therefore accepted by the HTTP receiver as a report, even though events must be objects; its original payload is also lost. Reject non-object elements (and have each transport classify/ignore that decode failure) instead of fabricating an event with zero IDs and empty raw JSON.
The codec computes subType but does not pass it to GroupBan, so both ban and lift_ban events expose subType == null despite the public model having that property. Preserve the wire value as the other notice decoders do.
Optional segment parameters are emitted as explicit JSON null values. explicitNulls = false does not remove JsonNull already inserted into a manually built object, so a simple Image(file = ...) sends null type, url, flags, and timeout instead of omitting optional fields as required by the request contract. Add each optional key only when non-null; the same pattern recurs in the record, video, poke, share, location, music, and node branches.
Rejected reports have already been enqueued and counted as accepted before the callback's decision. This contradicts Rejected's contract and lets downstream processing act on a request answered with 4xx. Decide first, and only report/increment the accepted and quick-operation branches.
Rejection bodies are plain text, but this labels every non-204 body as JSON. HTTP clients that honor the media type will try to parse text such as the signature... as JSON and fail to expose the actual reason. Use text/plain; charset=utf-8 for detail, retaining JSON only for quick-operation objects.
…e drops
Five findings of the second review, all of them about a connection being
trusted for more than it is.
The HTTP receiver gave every accepted connection a thread of its own with no
bound and no limit on the whole request, so a client sending a byte at a time
held a thread for as long as it liked: `soTimeout` measures the gap between
two reads, not the request. The readers are now a bounded pool with a bounded
queue, a connection past that is answered 503 as busy instead of being held,
and the whole request carries a deadline that the head and the body are both
checked against.
The reverse listener read frames from every socket the library had upgraded,
not from the peer it serves, so a connection it had refused or one that a
newer peer replaced could report an event or answer a call of the connection
being served. Frames of a socket that is not the active peer are ignored now.
Its close was read the same way: a refused or superseded socket closing ended
every waiting call of the active connection, and now a close says nothing
about a connection that is not the one closing.
The count of dropped events was derived from the room the queue had, which
made it shrink as a reader caught up and reach zero once it had. The queue
names each event it never delivers, which is what is counted instead, so the
count only grows.
The header case test named its account in the body as well as the header, so
it was taken whichever of the two was read and passed even with the header
lookup broken. The body names no account now, which makes being taken the
header path working and nothing else.
Verified: :libraries:channels:onebot:test passes, 83 tests.
Findings of the second review that the first pass of this branch had not
read, taken from the review body rather than from its threads.
A report this instance refuses was put on the queue and counted as accepted
before the decision that refused it was taken, so a report the implementation
was told 4xx about reached whoever reads the events. The decision is taken
first now, and only an accepted or quick-operation report is reported and
counted.
Every body that was not 204 was answered as `application/json`, including the
sentence that says why a report was refused, which a client honouring the
type would fail to parse instead of showing the reason. A quick operation
stays JSON; a refusal is `text/plain; charset=utf-8`.
A notice of a group ban decoded `sub_type` into `isBan` alone and dropped it,
so a `ban` and a `lift_ban` were told apart but neither said which it was.
The wire value is kept, as the other notice decoders keep it.
A call over a socket threw where a call over HTTP answered: a socket that is
gone threw `IllegalStateException` and a call past its timeout threw
`TimeoutCancellationException`, so a channel sending a reply failed the turn
instead of reporting a delivery that did not happen. Both become
`OneBotResult.Unreachable`; a caller that gave up still travels as it is.
The mapping of a failure to a delivery read the kind of the failure and not
whether the same call could succeed again, so an answer of 5xx, which the
transport calls retryable, reached the caller as one that is not. It is asked
first now.
An optional parameter of a segment was written as a JSON null instead of
being left out, which the standard's request contract has no room for: an
implementation reading `type` as a string met a null. Every optional
parameter is written only when it is present.
Verified: :libraries:channels:onebot:test passes, 84 tests.
Two findings of the second review that the first pass of this branch had not
read.
A group message named the chat with the card of whoever sent it, which is
that member's own display name and not the name of the group: every group
would be labelled with the last person who spoke. A message carries no name
of its group, so the title is null until something asks for it.
A scalar or an array handed to the event decoder became an event that named
no time, no account and no kind, and its own payload was thrown away, so a
report that is no event was accepted as one worth reading. It is refused now,
which the transports already answer as a report they cannot read.
Verified: :libraries:channels:onebot:test passes, 84 tests.
…sage
Two findings of the second review that were left open.
A typed request wrote its ids as JSON strings, bypassing the serializers
that emit a number when the id fits in a `Long`, and the standard declares
these parameters numeric: a strict implementation is free to refuse a call
it would otherwise take. `UserId`, `GroupId` and `MessageId` now go through
the same numeric serializer the segments use, falling back to a string only
when the id is too large for one.
A reply named its target by constructing a numeric `MessageId` from whatever
the reference held, and this mapper is also what names a delivery `sent` or
`async` when the implementation answered with no id. Answering such a message
therefore threw before anything was sent, turning a lost thread into a lost
reply. A reference that names no message of the implementation is left out of
the answer's segments, so the answer is sent.
Verified: :libraries:channels:onebot:test passes, 85 tests.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved configuration, protocol, security, and WebSocket lifecycle defects can cause startup failure, data corruption, denial of service, and delayed calls.
Values shorter than four characters are deliberately left unmasked, although access tokens and signing secrets have no minimum length. A non-2xx implementation response can echo such a token into OneBotFailure.message, violating this API's no-secret guarantee. Redact every non-empty configured value instead.
A socket response always carries this client's echo, but that value is only a correlation key, not the platform's message ID; toString() also includes JSON quotes for a string echo. When a successful send omits message_id, this therefore exposes a fabricated quoted ID instead of the documented sent fallback. Do not use echo as a message reference.
CQ escaping treats plain text and parameter values differently: commas are escaped only inside parameter values. Because this shared function also encodes Text, ordinary text commas become non-standard ,; the matching decoder also corrupts a literal , received in plain text. Split text escaping/unescaping from parameter escaping/unescaping.
Fail pending calls when closing the forward WebSocket
Closing a forward WebSocket does not complete or clear client.awaiting. Cancelling the connection loop can prevent its post-onClosefailPending call, so callers can remain suspended until firstByteTimeoutMillis after close() has returned. Atomically stop new sends and fail all pending deferreds before cancelling the scope.
Findings of the third review, which cover the listener, the sockets and the
credentials.
A configured value shorter than four characters was left in the clear, so a
short access token could be echoed back by an implementation and reach a
failure message. Nothing says a token is long, so every value that is not
empty is now replaced.
An answer over a socket carries the `echo` this client sent to match it to
its call, and this is not an id the implementation gave the message: a
delivery that named it invented one, quoted, where the documented fallback is
`sent`. The echo is no longer read as a name.
The declared body of a report over the limit was dropped without a deadline,
so a sender dripping it just faster than the idle timeout held a reader for
as long as it liked. The drain is under the same deadline as the request, and
its case now sends for longer than the deadline so that a listener without
one fails rather than passes.
Closing a forward connection cancelled the scope before the close handler
could end the calls that were waiting, so a caller stayed suspended past
`stop` until its own timeout. The waiting calls are ended first.
A reverse peer that replaced the one being served took over the connection
while the calls sent on the peer it replaced stayed in the map, waiting for
an answer that could no longer be read. Those calls are ended and the peer
they were sent on is closed before the newer one is served.
Escaping a comma was shared by plain text and by parameter values, but a
comma only ends a parameter: ordinary text had every comma turned into
`,`, and a literal `,` in text was read back as a comma. Text and
parameter values have their own escaping now, and the two cases that asserted
the old behaviour assert the new one.
Verified: :libraries:channels:onebot:test passes, 85 tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
OneBot v11 的客户端 Channel,位于 libraries/channels/onebot。
五个提交按层推进。先是协议域与动作注册表;然后是鉴权与四种通信;接着是消息映射;再把 Channel 与实例级 API 接起来;最后是 app 集成与示例配置。
协议域不涉及传输,也不引库。含 20 种消息段,认不出的段保留原始 data;三种消息形态;CQ 编解码;四类事件,认不出的保留原始 JSON;38 个公开动作与隐藏动作,
_async和_rate_limited由后缀派生;结果分层。四种通信各自独立,每实例选一种。http 的对测覆盖成功、拒绝、连不上、鉴权失败、畸形应答,另有 token 不泄漏的断言;http_post 覆盖验签、路径、账号绑定与快速操作回写;ws 按 echo 关联读回应答并读事件;ws_reverse 由对端拨号接入,并经该连接完成一次调用。另有一个运行于真实 runtime 的集成测试,覆盖实现上报消息至回复经 send_private_msg 发出。
依赖方面引入 org.java-websocket:Java-WebSocket:1.6.0,仅出现在插件模块与版本目录,plugin-sdk、internal、runtime 未改动,唯一传递依赖为 slf4j-api。
以下边界本 PR 不处理:
验证命令为
./gradlew :libraries:channels:onebot:spotlessCheck :libraries:channels:onebot:test,74 个测试通过,spotless 与 allWarningsAsErrors 均通过。:app:test在本分支存在的失败与本 PR 无关,分两类。一类是既有平台基线:断言写死 POSIX 的路径与错误文案,运行在 Windows 上;已在父提交用 stash 复测,失败集合一致;这批由 fix/platform-dependent-assertions 分支单独修复。另一类是上游 e7747d8 引入的:Floor.kt 把 Path.of("/dev") 写入默认保护位置清单,Windows 上该路径为无盘符的\dev,PathCanonicalizer 无法处理,AgentFloor 构造失败导致 runtime 无法启动。该问题在 master 上同样存在,属产品代码的平台缺陷,另行处理。